Вы работаете в стартапе, который продаёт продукты питания. Нужно разобраться, как ведут себя пользователи вашего мобильного приложения.
Изучите воронку продаж. Узнайте, как пользователи доходят до покупки. Сколько пользователей доходит до покупки, а сколько — «застревает» на предыдущих шагах? На каких именно?
После этого исследуйте результаты A/A/B-эксперимента. Дизайнеры захотели поменять шрифты во всём приложении, а менеджеры испугались, что пользователям будет непривычно. Договорились принять решение по результатам A/A/B-теста. Пользователей разбили на 3 группы: 2 контрольные со старыми шрифтами и одну экспериментальную — с новыми. Выясните, какой шрифт лучше.
Каждая запись в логе — это действие пользователя, или событие:
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
import plotly.graph_objects as go
import math as mth
from scipy import stats as st
try:
df = pd.read_csv('logs_exp.csv', sep='\t')
except:
print('Ошибка подгрузки данных')
df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 244126 entries, 0 to 244125 Data columns (total 4 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 EventName 244126 non-null object 1 DeviceIDHash 244126 non-null int64 2 EventTimestamp 244126 non-null int64 3 ExpId 244126 non-null int64 dtypes: int64(3), object(1) memory usage: 7.5+ MB
df.sample(5)
| EventName | DeviceIDHash | EventTimestamp | ExpId | |
|---|---|---|---|---|
| 241618 | MainScreenAppear | 3663774452205401864 | 1565204489 | 248 |
| 50608 | OffersScreenAppear | 6567588976245816164 | 1564739234 | 247 |
| 186026 | MainScreenAppear | 6744431328476878046 | 1565080584 | 248 |
| 3489 | MainScreenAppear | 4737456653000319680 | 1564627370 | 248 |
| 43248 | MainScreenAppear | 3377270958483506175 | 1564723337 | 248 |
Из полученных данных видим, что необходмо:
df.rename(columns={"EventName": "event_name", "DeviceIDHash": "device_id", "EventTimestamp": "event_timestamp", "ExpId": "exp_id"}, inplace = True)
df.columns
Index(['event_name', 'device_id', 'event_timestamp', 'exp_id'], dtype='object')
df.event_name.value_counts()
MainScreenAppear 119205 OffersScreenAppear 46825 CartScreenAppear 42731 PaymentScreenSuccessful 34313 Tutorial 1052 Name: event_name, dtype: int64
"Воронка событий", данные корректны
df['timestamp'] = pd.to_datetime(df.event_timestamp, unit='s')
df['date'] = pd.to_datetime(df.timestamp).dt.date
df.sample()
| event_name | device_id | event_timestamp | exp_id | timestamp | date | |
|---|---|---|---|---|---|---|
| 25424 | OffersScreenAppear | 871780189849907585 | 1564671541 | 246 | 2019-08-01 14:59:01 | 2019-08-01 |
df.exp_id.value_counts()
248 85747 246 80304 247 78075 Name: exp_id, dtype: int64
Данные корректны, приведем их к более удобному для восприятия виду
df.loc[df.exp_id==246, 'exp_id'] = 'A1'
df.loc[df.exp_id==247, 'exp_id'] = 'A2'
df.loc[df.exp_id==248, 'exp_id'] = 'B'
df.sample(5)
| event_name | device_id | event_timestamp | exp_id | timestamp | date | |
|---|---|---|---|---|---|---|
| 58476 | PaymentScreenSuccessful | 4488981949755449740 | 1564752692 | B | 2019-08-02 13:31:32 | 2019-08-02 |
| 8334 | MainScreenAppear | 5355969265820406243 | 1564644511 | A2 | 2019-08-01 07:28:31 | 2019-08-01 |
| 5922 | OffersScreenAppear | 8295796382860759565 | 1564638640 | A2 | 2019-08-01 05:50:40 | 2019-08-01 |
| 57052 | MainScreenAppear | 6138239031026008373 | 1564750397 | A2 | 2019-08-02 12:53:17 | 2019-08-02 |
| 137990 | MainScreenAppear | 682524489572918618 | 1564946478 | A1 | 2019-08-04 19:21:18 | 2019-08-04 |
df.duplicated().sum()
413
df[df.duplicated(keep=False)].sort_values('device_id')
| event_name | device_id | event_timestamp | exp_id | timestamp | date | |
|---|---|---|---|---|---|---|
| 130995 | OffersScreenAppear | 33176906322804559 | 1564933763 | B | 2019-08-04 15:49:23 | 2019-08-04 |
| 130994 | OffersScreenAppear | 33176906322804559 | 1564933763 | B | 2019-08-04 15:49:23 | 2019-08-04 |
| 130558 | MainScreenAppear | 33176906322804559 | 1564933075 | B | 2019-08-04 15:37:55 | 2019-08-04 |
| 130557 | MainScreenAppear | 33176906322804559 | 1564933075 | B | 2019-08-04 15:37:55 | 2019-08-04 |
| 104106 | CartScreenAppear | 34565258828294726 | 1564857221 | B | 2019-08-03 18:33:41 | 2019-08-03 |
| ... | ... | ... | ... | ... | ... | ... |
| 200170 | PaymentScreenSuccessful | 9160437016685643194 | 1565104416 | A2 | 2019-08-06 15:13:36 | 2019-08-06 |
| 200171 | PaymentScreenSuccessful | 9160437016685643194 | 1565104416 | A2 | 2019-08-06 15:13:36 | 2019-08-06 |
| 204829 | PaymentScreenSuccessful | 9187990861085277398 | 1565110888 | A2 | 2019-08-06 17:01:28 | 2019-08-06 |
| 204831 | PaymentScreenSuccessful | 9187990861085277398 | 1565110888 | A2 | 2019-08-06 17:01:28 | 2019-08-06 |
| 204830 | PaymentScreenSuccessful | 9187990861085277398 | 1565110888 | A2 | 2019-08-06 17:01:28 | 2019-08-06 |
768 rows × 6 columns
df.drop_duplicates(inplace=True)
df.reset_index(drop=True, inplace=True)
print("Событий в логе:", df.shape[0])
Событий в логе: 243713
df.event_name.value_counts()
MainScreenAppear 119101 OffersScreenAppear 46808 CartScreenAppear 42668 PaymentScreenSuccessful 34118 Tutorial 1018 Name: event_name, dtype: int64
print("Уникальных пользователей в логе:", df.device_id.nunique())
Уникальных пользователей в логе: 7551
Распределение пользователей по экспериментальным группам
df.groupby('exp_id').agg({'device_id':'nunique'})
| device_id | |
|---|---|
| exp_id | |
| A1 | 2489 |
| A2 | 2520 |
| B | 2542 |
print("Среднее количество событий на одного пользователя:", round(df.shape[0]/df.device_id.nunique()))
Среднее количество событий на одного пользователя: 32
df.groupby('device_id').agg({'event_name':'count'}).describe().loc[['min','max','50%']]
| event_name | |
|---|---|
| min | 1.0 |
| max | 2307.0 |
| 50% | 20.0 |
В данных есть пользователи:
plt.figure(figsize =(15, 5))
plt.boxplot(df.groupby('device_id')['event_name'].count(), vert=False)
plt.title('"Ящик с усами"', fontsize=12)
plt.xlabel('Число событий', fontsize=10)
plt.ylabel('На одного пользователя',fontsize=10)
plt.show();
На графике видно большое число "выбросов"
print("Минимальная дата:",df.timestamp.min())
print("Маскимальная дата:",df.timestamp.max())
print("Общий период:", df.timestamp.max() - df.timestamp.min())
Минимальная дата: 2019-07-25 04:43:36 Маскимальная дата: 2019-08-07 21:15:17 Общий период: 13 days 16:31:41
Посчитаем число и долю событий по дням, построим гистограмму по дате и времени
event_by_days = df.groupby('date')['event_name'].count().reset_index()
event_by_days.columns = ['date','event_count']
event_by_days['ratio'] = event_by_days.event_count/event_by_days.event_count.sum()
event_by_days.style.format({'ratio': '{:.1%}'})
| date | event_count | ratio | |
|---|---|---|---|
| 0 | 2019-07-25 | 9 | 0.0% |
| 1 | 2019-07-26 | 31 | 0.0% |
| 2 | 2019-07-27 | 55 | 0.0% |
| 3 | 2019-07-28 | 105 | 0.0% |
| 4 | 2019-07-29 | 184 | 0.1% |
| 5 | 2019-07-30 | 412 | 0.2% |
| 6 | 2019-07-31 | 2030 | 0.8% |
| 7 | 2019-08-01 | 36141 | 14.8% |
| 8 | 2019-08-02 | 35554 | 14.6% |
| 9 | 2019-08-03 | 33282 | 13.7% |
| 10 | 2019-08-04 | 32968 | 13.5% |
| 11 | 2019-08-05 | 36058 | 14.8% |
| 12 | 2019-08-06 | 35788 | 14.7% |
| 13 | 2019-08-07 | 31096 | 12.8% |
df.timestamp.hist(bins=100, figsize=(15, 5), xlabelsize=10, ylabelsize=10)
plt.title('Распределение событий по дате и времени', fontsize=12)
plt.xlabel('Дата', fontsize=10)
plt.ylabel('Число событий', fontsize=10)
plt.xticks(rotation=45);
Мы видим, что данные до 1-го августа неполные, их следует отбросить
df_f = df.query('timestamp>="2019-08-01"').reset_index(drop=True)
print("Отфильтровано пользователей:", df.device_id.nunique()-df_f.device_id.nunique())
print("Доля отфильтрованных пользователей: {:.2%}".format((df.device_id.nunique()-df_f.device_id.nunique())/df.device_id.nunique()))
print("Всего пользователей после фильтрации:", df_f.device_id.nunique())
Отфильтровано пользователей: 17 Доля отфильтрованных пользователей: 0.23% Всего пользователей после фильтрации: 7534
print("Отфильтровано событий:", df.shape[0]-df_f.shape[0])
print("Доля отфильтрованных событий: {:.2%}".format((df.shape[0]-df_f.shape[0])/df.shape[0]))
print("Всего событий после фильтраци:", df_f.shape[0])
Отфильтровано событий: 2826 Доля отфильтрованных событий: 1.16% Всего событий после фильтраци: 240887
Распределение пользователей по экспериментальным группам до фильтрации
df.groupby('exp_id').agg({'device_id':'nunique'})
| device_id | |
|---|---|
| exp_id | |
| A1 | 2489 |
| A2 | 2520 |
| B | 2542 |
Распределение пользователей по экспериментальным группам после фильтрации
dis = df_f.groupby('exp_id').agg({'device_id':'nunique'}).reset_index()
dis.columns = ['exp_id','amount']
dis['ratio'] = dis.amount/dis.amount.sum()
dis.style.format({'ratio': '{:.1%}'})
| exp_id | amount | ratio | |
|---|---|---|---|
| 0 | A1 | 2484 | 33.0% |
| 1 | A2 | 2513 | 33.4% |
| 2 | B | 2537 | 33.7% |
Отфильтровано пользователей: A1-5, A2-7, B-5
Изучение данных показало, что тестирование проходило с 2019-07-25 по 2019-08-07, но в первую неделю данные были записаны в лог неполностью, поэтому все результаты до 2019-08-01 необходимо отбросить; по итогам фильтрации (-0.23% пользователей и -1.16% событий) имеем:
df_f.event_name.value_counts()
MainScreenAppear 117328 OffersScreenAppear 46333 CartScreenAppear 42303 PaymentScreenSuccessful 33918 Tutorial 1005 Name: event_name, dtype: int64
funnel_users = (df_f.groupby('event_name')['device_id'].nunique()
.sort_values(ascending=False)
.reset_index()
.rename(columns={'device_id': 'users_count'})
)
funnel_users['ratio'] = funnel_users['users_count'] / df_f['device_id'].nunique()
funnel_users.style.format({'ratio': '{:.1%}'})
| event_name | users_count | ratio | |
|---|---|---|---|
| 0 | MainScreenAppear | 7419 | 98.5% |
| 1 | OffersScreenAppear | 4593 | 61.0% |
| 2 | CartScreenAppear | 3734 | 49.6% |
| 3 | PaymentScreenSuccessful | 3539 | 47.0% |
| 4 | Tutorial | 840 | 11.1% |
plt.figure(figsize=(15, 5))
ax = sns.barplot(x='event_name', y='ratio', data=funnel_users, color='skyblue')
for i, val in enumerate(funnel_users.ratio.values):
plt.text(i, val, '{:.1%}'.format(val), horizontalalignment='center', verticalalignment='bottom',fontsize=10)
plt.title("Доля уникальных пользователей для каждого события", fontsize = 14)
plt.xlabel("Событие", fontsize = 12)
plt.ylabel("Доля пользователей", fontsize = 12);
1.5% пользователей не заходили на главную страницу вообще, но приложением при этом пользовались
Самое редкое событие - Tutorial, и если подходить с точки зрения цифр - это должен быть "низ воронки", но скорее всего Tutorial - это "справка" о том, как пользоваться приложением - большинству пользователей для базовых действий "справку" читать не обязательно, вот они в нее и не заходят.
Порядок действий в нашей событийной воронке такой:
funnel_filtered = (df_f.query('event_name!="Tutorial"').groupby('event_name')['device_id'].nunique()
.sort_values(ascending=False)
.reset_index()
.rename(columns={'device_id': 'users_count'})
)
funnel_filtered['conversion'] = funnel_users.users_count.pct_change() + 1
funnel_filtered.fillna(1, inplace=True)
funnel_filtered.style.format({'conversion': '{:.1%}'})
| event_name | users_count | conversion | |
|---|---|---|---|
| 0 | MainScreenAppear | 7419 | 100.0% |
| 1 | OffersScreenAppear | 4593 | 61.9% |
| 2 | CartScreenAppear | 3734 | 81.3% |
| 3 | PaymentScreenSuccessful | 3539 | 94.8% |
fig = go.Figure(go.Funnel(
x = funnel_filtered.users_count,
y = funnel_filtered.event_name,
textinfo = 'value+percent previous',
marker = {'color': 'green'}
) )
fig.update_layout(title={'text': "Воронка пользователей", 'xanchor': 'center', 'x':.55, 'y':.85})
fig.show();
print('Больше всего пользователей "теряется" после первого шага, таких -', funnel_filtered.users_count[0]-funnel_filtered.users_count[1])
Больше всего пользователей "теряется" после первого шага, таких - 2826
print('{:.1%} пользователей дошли от первого события до оплаты'.format(funnel_filtered.users_count[3] / funnel_filtered.users_count[0]))
47.7% пользователей дошли от первого события до оплаты
groups = df_f.groupby('exp_id').agg({'device_id':'nunique'}).T.reset_index(drop=True).rename_axis(None, axis=1)
groups['A1_A2']= groups['A1']+groups['A2']
groups
| A1 | A2 | B | A1_A2 | |
|---|---|---|---|---|
| 0 | 2484 | 2513 | 2537 | 4997 |
A1 и A2 - контрольные группы, B - экспериментальная
Проверим, есть ли пользователи принадлежащие более чем к одной группе
dbl_usr = df_f.groupby('device_id').agg({'exp_id':'nunique'})
dbl_usr.query('exp_id>1').count()
exp_id 0 dtype: int64
Таких пользователей не оказалось
Соберем сводную таблицу воронки событий по всем группам для z-теста
ztest_pt = (df_f.pivot_table(index='event_name', columns='exp_id', values='device_id',aggfunc='nunique')
.sort_values(by='A1', ascending=False)
.reset_index()
.rename_axis(None, axis=1)
)
ztest_pt['A1_A2'] = ztest_pt.A1 + ztest_pt.A2
ztest_pt['A1_ratio'] = ztest_pt.A1/dis.amount[0]
ztest_pt['A2_ratio'] = ztest_pt.A2/dis.amount[1]
ztest_pt['B_ratio'] = ztest_pt.A2/dis.amount[2]
ztest_pt['A1_A2_ratio'] = ztest_pt.A1_A2/(dis.amount[0]+dis.amount[1])
ztest_pt.style.format({'A1_ratio': '{:.1%}', 'A2_ratio':'{:.1%}', 'B_ratio':'{:.1%}', 'A1_A2_ratio': '{:.1%}'})
| event_name | A1 | A2 | B | A1_A2 | A1_ratio | A2_ratio | B_ratio | A1_A2_ratio | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | MainScreenAppear | 2450 | 2476 | 2493 | 4926 | 98.6% | 98.5% | 97.6% | 98.6% |
| 1 | OffersScreenAppear | 1542 | 1520 | 1531 | 3062 | 62.1% | 60.5% | 59.9% | 61.3% |
| 2 | CartScreenAppear | 1266 | 1238 | 1230 | 2504 | 51.0% | 49.3% | 48.8% | 50.1% |
| 3 | PaymentScreenSuccessful | 1200 | 1158 | 1181 | 2358 | 48.3% | 46.1% | 45.6% | 47.2% |
| 4 | Tutorial | 278 | 283 | 279 | 561 | 11.2% | 11.3% | 11.2% | 11.2% |
Самое популярное событие MainScreenAppear, по пользователям: A1 - 2450, A2 - 2476, B - 2493; по долям: A1 - 98.6%, A2 - 98.5%, B - 97.6%
Создадим функцию для проверки статистической разницы между группами
def z_test(group_1, group_2, alpha):
for i in ztest_pt.index:
n1 = groups[group_1]
n2 = groups[group_2]
p1 = ztest_pt[group_1][i] / n1
p2 = ztest_pt[group_2][i] / n2
p = (ztest_pt[group_1][i] + ztest_pt[group_2][i]) / (n1 + n2)
z_value = (p1-p2)/mth.sqrt(p * (1-p) * (1/n1 + 1/n2))
distr = st.norm(0, 1)
p_value = (1 - distr.cdf(abs(z_value))) * 2
print('Событие {}, p-значение: {:.4f}'.format(ztest_pt['event_name'][i], p_value[0]))
if (p_value < alpha):
print("Нулевая гипотеза отвергнута: разница между долями статистически значима\n")
else:
print("Нулевая гипотеза не отвергнута: между долями в группах нет статистически значимой разницы\n")
Сформулируем гипотезы и проверим контрольные группы с помощью функции z_test, уровень статистической значимости выберем 0.05:
z_test("A1", "A2", 0.05)
Событие MainScreenAppear, p-значение: 0.7571 Нулевая гипотеза не отвергнута: между долями в группах нет статистически значимой разницы Событие OffersScreenAppear, p-значение: 0.2481 Нулевая гипотеза не отвергнута: между долями в группах нет статистически значимой разницы Событие CartScreenAppear, p-значение: 0.2288 Нулевая гипотеза не отвергнута: между долями в группах нет статистически значимой разницы Событие PaymentScreenSuccessful, p-значение: 0.1146 Нулевая гипотеза не отвергнута: между долями в группах нет статистически значимой разницы Событие Tutorial, p-значение: 0.9377 Нулевая гипотеза не отвергнута: между долями в группах нет статистически значимой разницы
С помощью функции z_test проведем A/B-тестирование между группами, уровень статистической значимости выберем 0.05:
z_test("A1", "B", 0.05)
Событие MainScreenAppear, p-значение: 0.2950 Нулевая гипотеза не отвергнута: между долями в группах нет статистически значимой разницы Событие OffersScreenAppear, p-значение: 0.2084 Нулевая гипотеза не отвергнута: между долями в группах нет статистически значимой разницы Событие CartScreenAppear, p-значение: 0.0784 Нулевая гипотеза не отвергнута: между долями в группах нет статистически значимой разницы Событие PaymentScreenSuccessful, p-значение: 0.2123 Нулевая гипотеза не отвергнута: между долями в группах нет статистически значимой разницы Событие Tutorial, p-значение: 0.8264 Нулевая гипотеза не отвергнута: между долями в группах нет статистически значимой разницы
z_test("A2", "B", 0.05)
Событие MainScreenAppear, p-значение: 0.4587 Нулевая гипотеза не отвергнута: между долями в группах нет статистически значимой разницы Событие OffersScreenAppear, p-значение: 0.9198 Нулевая гипотеза не отвергнута: между долями в группах нет статистически значимой разницы Событие CartScreenAppear, p-значение: 0.5786 Нулевая гипотеза не отвергнута: между долями в группах нет статистически значимой разницы Событие PaymentScreenSuccessful, p-значение: 0.7373 Нулевая гипотеза не отвергнута: между долями в группах нет статистически значимой разницы Событие Tutorial, p-значение: 0.7653 Нулевая гипотеза не отвергнута: между долями в группах нет статистически значимой разницы
z_test("A1_A2", "B", 0.05)
Событие MainScreenAppear, p-значение: 0.2942 Нулевая гипотеза не отвергнута: между долями в группах нет статистически значимой разницы Событие OffersScreenAppear, p-значение: 0.4343 Нулевая гипотеза не отвергнута: между долями в группах нет статистически значимой разницы Событие CartScreenAppear, p-значение: 0.1818 Нулевая гипотеза не отвергнута: между долями в группах нет статистически значимой разницы Событие PaymentScreenSuccessful, p-значение: 0.6004 Нулевая гипотеза не отвергнута: между долями в группах нет статистически значимой разницы Событие Tutorial, p-значение: 0.7649 Нулевая гипотеза не отвергнута: между долями в группах нет статистически значимой разницы